0. 概念

MVCC (Multi-Version Concurrency Control) 中文全称叫多版本并发控制,是现代数据库(如MySQL)引擎实现中常用的处理读写冲突的手段,目的在于提高数据库高并发场景下的吞吐性能

在MVCC机制下,每一个写操作都会创建一个新版本的数据,读操作会从有限多个版本的数据中挑选一个最合适的结果直接返回;在这时,读写操作之间的冲突就不再需要被关注,而管理和快速挑选数据的版本就成了 MVCC 需要解决的主要问题

MVCC 并不是一个与乐观和悲观并发控制对立的东西,它能够与两者很好的结合以增加事务的并发量,在目前最流行的 SQL 数据库 MySQL 和 PostgreSQL 中都对 MVCC 进行了实现;但是由于它们分别实现了悲观锁和乐观锁,所以 MVCC 实现的方式也不同

悲观锁: 事务需要对资源进行操作时需要先获得资源对应的锁,保证其他事务不会访问该资源后,在对资源进行各种操作;在悲观并发控制中,数据库程序对于数据被修改持悲观的态度,在数据处理的过程中都会被锁定,以此来解决竞争的问题

乐观锁:乐观并发控制也叫乐观锁,但是它并不是真正的锁,可能误以为乐观锁是一种真正的锁,然而它只是一种并发控制的思想,它的核心思想是:在事务提交之前,不会对数据进行加锁,而是在事务提交时检查是否有其他事务对数据进行了修改,如果检测到冲突,乐观锁机制会回滚当前事务并重新尝试,以避免数据不一致性

MySQL的InnoDB存储引擎默认事务隔离级别是可重复读 (Repeatable Read),是通过 <行级锁+MVCC> 一起实现的,正常读的时候不加锁,写的时候加锁。而 MCVV 的实现依赖:隐藏字段、Read View、Undo log

事务:事务是关系型数据库中的一个重要概念,它是一组操作序列的集合,这些操作被视为一个整体,要么全部执行成功,要么全部不执行,以确保数据的完整性和一致性

一个事务可以包含多个SQL语句,例如SELECT、INSERT、DELETE、UPDATE等操作。在事务开始时,它会获取相应的锁,以确保其操作不会受其他事务的干扰。事务执行完成后,可以通过COMMIT来提交事务,将所做的修改永久保存。如果需要撤销事务的修改,可以通过ROLLBACK来回滚事务。如果事务因异常情况终止,系统也会自动回滚事务,将数据库恢复到事务开始前的状态

MVCC只在 Read CommittedRepeatable Read 两个隔离级别下工作,其他两个隔离级别和MVCC不兼容:

  • Read Uncommitted: 总是读取最新的记录行,不需要MVCC的支持
  • Serializable: 会对所有读取的记录行都加锁,单靠MVCC无法完成

说白了 MVCC 就是为了实现读 - 写冲突不加锁,而这个读指的就是快照读, 而非当前读,当前读实际上是一种加锁的操作,是悲观锁的实现

当前读: 指读取的是记录数据的最新版本,并且当前读返回的记录都会加上锁,保证其他事务不会再并发地修改这条记录

快照读: 读取的是可见的数据版本(可能是过期的数据版本),不需要加锁

1. 依赖

a. 隐藏字段

在应用InnoDB存储引擎时,会向每一个数据行记录额外插入3个隐藏字段,分别是DB_TRX_IDDB_ROLL_PTRDB_ROW_ID

  • DB_TRX_ID: 表示最近一次对本记录做修改(update | insert)的事务ID,InnoDB将delete操作认为是一次update
  • DB_ROLL_PTR: 回滚指针,记录当前行的undo log信息,占7个字节
  • DB_ROW_ID: 随着新的数据行插入而单调递增的行ID,当表没有主键或者唯一非空索引时,将会使用这个行ID产生一个聚簇索引,这个字段与MVCC关系并不大,占6个字节

b. Read View

Read View 是一个结构体,记录了该时刻的事务活跃快照信息,当读取一行记录时,根据该行记录的版本信息和当前事务的 Read View 来判断该行记录是否可见

下图是某一时刻各个事务的活跃状态,由回滚指针(RollPtr)串联了ID为21474836的各个事务下数据行Undo Log历史版本记录链,图中一共包含了A/B/C三个事务,其中根据图示得知事务A为已提交状态,事务B / C为未提交状态,基于此在查询该记录行时,对于该时刻查询的可见范围为事务A,其对应的NAME值为A

image-20230730165025281

在 InnoDB 中,一个事务在读取数据时,需要通过 Read View 来判断当前读取的数据版本是否可见,这个结构体是由当前事务的启动时刻确定的,包含了当前事务开始时刻已经提交的事务的事务 ID,Read View 中的每个事务 ID在查询readview生成时刻的活跃状态

它主要用作数据行可见性的判断,也正是因为Read View的生成时机不同照成了RC和RR两种隔离级别的不同可见性:

  • REPEATABLE READ: 事务在begin / start transaction之后的第一条SELECT读操作后,会创建一个Read View,将当前系统中活跃的其他事务操作记录下来
  • READ COMMITTED: 事务的每一个SELECT语句都会创建一个Read View

c. Undo Log

用于回滚和MVVC,通俗的理解的话,Undo Log实际上就是记录一个相反操作的记录,用于后续的突发情况下的恢复和回滚操作,而相反的,Redo Log是重做日志,提供前滚操作,当事务进行了变更操作时,就会产生Undo记录,Undo记录默认会被记录到系统表空间(.ibd文件-innodb data)中,但从5.6版本开始,可以使用独立的Undo表空间

Undo记录中存储的是数据行老版本的数据,当一个事务需要读取数据时,为了能读取到该事务可见范围内的数据,需要顺着Undo链找到满足其可见性的记录,对数据进行变更操作时,会产生Undo日志,其中INSERT操作会在事务提交前只对当前事务可见

如下图展示的是Undo日志链,记录的是一个数据行的完整历史版本,其中每一行通过回滚指针进行串联:

image-20230730165005534

Undo Log采用的段「Segement」的方式来记录,每个Undo操作在记录的时候会占用一个Undo Log Segement,如下图所示:

undo-log-segement
  • Trx ID: 当前的事务ID
  • Trx No: 事务的顺序编号
  • Delete Mark: 删除标志,当回滚的时候可以根据该标志位判断记录是否可见,避免无意义的Undo日志行扫描
  • Log Start Offset: 记录的是Undo Log Header的结束偏移量
  • XID and DDL Flags: DDL标志位
  • Table ID if is DDL: 当改变的是表结构时需要记录表ID
  • Next Undo Log: 下一条回滚日志记录
  • Prev Undo Log: 上一条回滚日志记录
  • History List Node: 回滚日志链
  • Undo Record: 该事务下特定行记录产生的回滚记录

2. 原理

在上述提到了InnoDB存储引擎实现的MVCC依赖了MySQL数据行的隐藏字段、Read View和Undo Log,通过下图我们可以完整得结合上述概念:

image-20230730164930407

上方是关于事务ID为20提交后进行查询生成的Read View和数据行Undo日志链,该Read View在创建时,会根据当前系统的活跃事务列表来确定出最小未提交活跃事务ID[min_id], 下次创建的事务id[max_id]以及创建Read View的事务ID,根据其可确定事务回滚的最终位置以及查询时数据记录的可见范围,以下是版本比对规则:

  • 如果数据行的事务ID落在了[1, min_id)区间,则表示该数据行是可见的
  • 如果数据行的事务ID落在了[max_id, ∞)区间,则表示该数据行是不可见的
  • 如果数据行的事务ID落在了[min_id, max_id)区间,则需要按以下两种情况处理
    • 如果事务ID在活跃事务ID列表中或当前事务ID就是该事务ID,则说明该记录对于当前事务来说是已开始但未提交的事务生成的,那么对于当前事务不可见
    • 如果事务ID不在活跃事务ID列表中,则说明该版本对于当前事务来说,是已提交的事务生成的,那么对于当前事务可见

因此,基于事务ID为20提交后的SELECT * FROM USER WHERE ID = 1;,根据Read View中的判断规则,对该数据行进行可见性判断,事务ID为20的记录在可见范围(20 < min_id),因此查询得到的NAME值为Q

3. 总结

  • MVCC主要靠ReadView来实现一致性读,也就是快照读,底层是主要基于两个隐藏字段来实现(DB_TRX_ID, DB_ROLL_PTR),这样可以使得不同事务的读-写,写-读操作并发执行,从而提升系统性能
  • ReadView一旦被创建,则不会再发生变化
  • MVCC只在可重复读读已提交两个隔离级别中生效:
    • 可重复读: 其ReadView在首次查询时创建,也就是事务中第一条SELECT语句的瞬间,后续所有的SELECT都是复用的这个ReadView,因此能够保证每一次读取的一致性
    • 读已提交: 每次读取都会创建一个新的ReadView,因此能够读取到其他事务已经COMMIT的数据
  • 读未提交不需要ReadView,也不需要关注隐藏字段,可以直接读取最新的记录
  • begin/start transaction命令并不是一个事务的起点,在执行到它们之后的第一个修改操作InnoDB表的语句,事务才真正启动,才会向MySQL申请事务ID,MySQL内部严格按照事务的启动顺序来分配事务ID